Skip to content

feat(agent): proto + ConnectRPC skeleton with Health RPC (FJB-94)#107

Merged
hstern merged 3 commits into
mainfrom
feat/fjb-94-agent-skeleton
May 28, 2026
Merged

feat(agent): proto + ConnectRPC skeleton with Health RPC (FJB-94)#107
hstern merged 3 commits into
mainfrom
feat/fjb-94-agent-skeleton

Conversation

@hstern

@hstern hstern commented May 28, 2026

Copy link
Copy Markdown
Owner

Summary

First slice of the fjbagent foundation (FJB-94). Lands the proto package + binary skeleton + Health RPC. No deploy story yet (cloud-init, systemd, cache base-image come in Phase B/C), no consumer code yet in the orchestrator (agent.Dialer is Phase C). Self-contained, local-only.

  • proto/fjbellows/agent/v1/agent.protoAgentService.Health returning build version + start time + hostname. Forward-compatible shape; FJB-93 adds Exec, FJB-97 reuses Health for the gRPC reachability probe.
  • cmd/fjbagent — static Go binary, ConnectRPC server, bearer auth, plain /healthz shim. Flags: -listen, -token-file, -log-level.
  • internal/agentHandler captures process-scope health facts once at construction; bearer middleware matches the internal/control shape (constant-time compare, /healthz exempt, empty token disables for tests). LoadToken rejects empty / whitespace-only files.

Test plan

  • go test ./internal/agent/ — 8 cases (Health, bearer accept/reject/wrong-token, /healthz exempt, graceful shutdown on ctx cancel, LoadToken happy/empty/whitespace/missing)
  • go test -race ./internal/agent/
  • go test ./... — full repo, no regressions
  • go vet ./..., gofmt, make proto-check clean
  • go build ./cmd/fjbagent produces a working binary with -h output

No Linode e2e required for this slice — no infra code path is touched yet. e2e gate kicks in for Phase B (worker cloud-init) and Phase C (cache deploy + orchestrator dialer).

Design

Per docs/designs/remote-shell.md (FJB-93's design doc, parent of the agent shape). Bearer auth pattern lifted from internal/control/auth.go so workers and the cache have a familiar auth posture even though token sources differ.

🤖 Generated with Claude Code

First slice of the fjbagent foundation. Lands the proto package, the
binary skeleton, and the Health RPC — no deploy story yet (cloud-init,
systemd, cache base-image come in subsequent slices), no consumer code
in the orchestrator yet (agent.Dialer is its own slice).

- proto/fjbellows/agent/v1/agent.proto: AgentService.Health
- cmd/fjbagent: static binary, ConnectRPC server, bearer auth, /healthz
  shim. -listen and -token-file flags (operator sets these from the
  systemd unit when deployment lands).
- internal/agent: Handler captures build version + start time +
  hostname at construction; bearer middleware matches the
  internal/control auth shape (constant-time compare, /healthz
  exempt).
- Unit tests: Health response shape, bearer accept/reject, /healthz
  open without token, graceful shutdown, LoadToken validation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@hstern hstern enabled auto-merge (squash) May 28, 2026 16:27
hstern and others added 2 commits May 28, 2026 13:35
…B-94)

gocritic flagged exitAfterDefer: os.Exit bypasses deferred cancel(),
so the signal-context goroutine leaks on the error path. Moving the
work into run() that returns error lets the defer fire before main
maps the error to an exit code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@hstern hstern merged commit d026781 into main May 28, 2026
7 checks passed
hstern added a commit that referenced this pull request May 28, 2026
* feat(agent): proto + ConnectRPC skeleton with Health RPC (FJB-94) (#107)

* feat(agent): proto + ConnectRPC skeleton with Health RPC (FJB-94)

First slice of the fjbagent foundation. Lands the proto package, the
binary skeleton, and the Health RPC — no deploy story yet (cloud-init,
systemd, cache base-image come in subsequent slices), no consumer code
in the orchestrator yet (agent.Dialer is its own slice).

- proto/fjbellows/agent/v1/agent.proto: AgentService.Health
- cmd/fjbagent: static binary, ConnectRPC server, bearer auth, /healthz
  shim. -listen and -token-file flags (operator sets these from the
  systemd unit when deployment lands).
- internal/agent: Handler captures build version + start time +
  hostname at construction; bearer middleware matches the
  internal/control auth shape (constant-time compare, /healthz
  exempt).
- Unit tests: Health response shape, bearer accept/reject, /healthz
  open without token, graceful shutdown, LoadToken validation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent): split main into run() so defer cancel() runs on error (FJB-94)

gocritic flagged exitAfterDefer: os.Exit bypasses deferred cancel(),
so the signal-context goroutine leaks on the error path. Moving the
work into run() that returns error lets the defer fire before main
maps the error to an exit code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(agent): Exec bidi RPC, pipe-mode handler (FJB-93 Phase A) (#108)

First slice of FJB-93. Lands the AgentService.Exec bidi-streaming RPC
on the agent and a pipe-mode handler (no PTY yet — that's Phase C).
The orchestrator-side ShellSession proxy and the fjbctl ssh CLI come
in Phase B; the wire envelope shape (ShellMsg / ShellEvent) is
already designed to support both phases without a re-do.

Wire shape:
  - ShellMsg: oneof Open | Stdin | Resize | Signal | CloseStdin
  - ShellEvent: oneof Opened | Stdout | Stderr | Exit
  - ExecOpen: target (set by fjbctl, stripped by orchestrator), argv,
    env, tty (rejected with CodeUnimplemented this phase), term,
    winsize
  - ExecExit: code on normal exit, signal name on signal kill

Handler behavior:
  - First frame must be Open or returns CodeInvalidArgument.
  - tty=true returns CodeUnimplemented.
  - argv exec'd directly (no shell interpolation); env is exact (no
    agent-env passthrough, matches the design doc).
  - exec.Cmd.Stdout/Stderr go through a streamWriter that feeds a
    buffered sendBuf channel; a single sender goroutine owns the
    bidi stream's Send side so we never need to mutex Connect's
    BidiStream.
  - cmd.Wait blocks until both child exit AND exec's internal copy
    goroutines have drained, so all output frames precede Exit by
    construction (avoids the StdoutPipe-vs-Wait race).
  - Child runs in a fresh process group (Setpgid); signals are
    delivered to the whole group via kill(-pgid).
  - Command-not-found surfaces as on-stream Exit{127} (sh
    convention); signal kills surface as Exit{code: 128+signum,
    signal: <name>}.

Tests: 11 cases covering echo, stdin pump + CloseStdin, non-zero
exit, stderr capture, env passthrough, tty/empty-argv rejection,
command-not-found, signal kill, first-frame-must-be-Open, bearer
auth gating. `go test -race -count=10` clean.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(linode): cache cloud-init WG bootstrap — Phase A (FJB-99)

Foundation for the per-deployment ephemeral cache flow that FJB-91
called out as missing: each cache provisioned by fj-bellows now comes
up with its own WG keypair, brings up wg-quick@wg0 referencing the
orchestrator's pubkey, and publishes its own pubkey to S3 so the
orchestrator can read it back in Phase B.

What lands (Phase A):
- main.go loads or generates the orchestrator's WG keypair BEFORE
  prov.Configure runs (was deferred until wgboot.Boot). Pubkey is
  pushed into the Linode provider via SetOrchestratorWGPubkey for the
  cache cloud-init injector.
- managedCache carries orchestratorWGPubkey and renders four new
  fields into cacheCloudInitParams (OrchestratorWGPubkey, Orchestrator-
  WGAddr, CacheWGAddr, WGListenPort) when transport.mode is
  cache-gateway.
- cache-cloud-init.yaml.tmpl conditionally installs wireguard +
  awscli, drops /usr/local/sbin/fjb-wg-bootstrap.sh, and runs it from
  runcmd. The script:
  - generates a Curve25519 keypair on first boot (idempotent — re-
    runs reuse the persisted private key);
  - writes /etc/wireguard/wg0.conf with the orchestrator pubkey baked
    in as [Peer].PublicKey and the cache's address pinned at
    100.64.0.2/32;
  - brings up wg-quick@wg0;
  - publishes /etc/wireguard/publickey to s3://<bucket>/wg-pubkey.txt
    using the same Object Storage credentials already shared with
    zot — no new identity to provision.
- ssh-mode renders byte-identical to before (the WG bootstrap block
  is gated on OrchestratorWGPubkey).

Out of Phase A (lands in Phase B/C):
- Orchestrator-side WaitForWGPubkey polling against the bucket +
  feeding the discovered pubkey into wgboot.Boot as the peer config.
- bootWGStack ordering reorder (today still wgboot before cache.Ensure;
  Phase B moves it after).
- e2e harness reactivation: drop the persistent-cache preflight, use
  the ephemeral cache + bootstrap, and reactivate the worker readiness
  + job-complete checks.
- Drop the static transport.wg.peer.{public_key,endpoint} config
  knobs.

Tests cover the cache-gateway and ssh-mode render branches; verified
end-to-end against live Linode via test/e2e-linode/run-local.sh
--transport=cache-gateway (stack-up scope from FJB-91 still
passes — the new code path is exercised but worker validation is
the Phase C deliverable).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
hstern added a commit that referenced this pull request May 28, 2026
…FJB-98) (#109)

* feat(linode): bake fjb-iptables FORWARD chain into cache cloud-init (FJB-98)

Cache nanodes provisioned by fj-bellows in cache-gateway mode now come
up with the FJB-FORWARD chain installed at first boot, persisted via
iptables-persistent. Previously the chain was only rendered into a log
line (`wgboot: cache iptables re-rendered (TODO: push via SSH)`) and
had to be hand-installed — the persistent test nanode for FJB-91 was
the only place this happened, and a rebuild would lose the FORWARD
policy entirely.

What lands:
- managedCache learns the worker VPC CIDR via setHardwareContext, then
  renders cachegateway.RenderCacheIPTables at cache-create time and
  embeds the script in cloud-init under /usr/local/sbin/fjb-iptables.sh.
- cache-cloud-init.yaml.tmpl conditionally installs iptables-persistent,
  writes the script, runs it from runcmd, and saves via
  netfilter-persistent. ssh-mode renders byte-identical to before.
- cachegateway Inputs.CacheVPCIP is now optional: the renderer never
  used it in the script body, and the bake-in happens before any VPC
  IP has been assigned. wgboot still passes it (eager Status lookup).

ACL-derived per-prefix ACCEPT rules are intentionally omitted from the
bake-in: the ACL registry isn't wired until wgboot starts (post-Configure),
so the script that lands at create time is the skeleton (sysctl +
ESTABLISHED,RELATED + FJB-92 reverse-direction + DROP). Runtime push of
ACL-updated rules is the wgboot TODO already noted in code; it lands
with FJB-99's ephemeral-cache bootstrap loop.

Tests cover the cache-gateway and ssh-mode render branches and the
renderIPTablesForCacheGateway transport-mode guard. Verified end-to-end
against live Linode via test/e2e-linode/run-local.sh --transport=cache-gateway.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(linode): cache cloud-init WG bootstrap — Phase A (FJB-99) (#110)

* feat(agent): proto + ConnectRPC skeleton with Health RPC (FJB-94) (#107)

* feat(agent): proto + ConnectRPC skeleton with Health RPC (FJB-94)

First slice of the fjbagent foundation. Lands the proto package, the
binary skeleton, and the Health RPC — no deploy story yet (cloud-init,
systemd, cache base-image come in subsequent slices), no consumer code
in the orchestrator yet (agent.Dialer is its own slice).

- proto/fjbellows/agent/v1/agent.proto: AgentService.Health
- cmd/fjbagent: static binary, ConnectRPC server, bearer auth, /healthz
  shim. -listen and -token-file flags (operator sets these from the
  systemd unit when deployment lands).
- internal/agent: Handler captures build version + start time +
  hostname at construction; bearer middleware matches the
  internal/control auth shape (constant-time compare, /healthz
  exempt).
- Unit tests: Health response shape, bearer accept/reject, /healthz
  open without token, graceful shutdown, LoadToken validation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent): split main into run() so defer cancel() runs on error (FJB-94)

gocritic flagged exitAfterDefer: os.Exit bypasses deferred cancel(),
so the signal-context goroutine leaks on the error path. Moving the
work into run() that returns error lets the defer fire before main
maps the error to an exit code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(agent): Exec bidi RPC, pipe-mode handler (FJB-93 Phase A) (#108)

First slice of FJB-93. Lands the AgentService.Exec bidi-streaming RPC
on the agent and a pipe-mode handler (no PTY yet — that's Phase C).
The orchestrator-side ShellSession proxy and the fjbctl ssh CLI come
in Phase B; the wire envelope shape (ShellMsg / ShellEvent) is
already designed to support both phases without a re-do.

Wire shape:
  - ShellMsg: oneof Open | Stdin | Resize | Signal | CloseStdin
  - ShellEvent: oneof Opened | Stdout | Stderr | Exit
  - ExecOpen: target (set by fjbctl, stripped by orchestrator), argv,
    env, tty (rejected with CodeUnimplemented this phase), term,
    winsize
  - ExecExit: code on normal exit, signal name on signal kill

Handler behavior:
  - First frame must be Open or returns CodeInvalidArgument.
  - tty=true returns CodeUnimplemented.
  - argv exec'd directly (no shell interpolation); env is exact (no
    agent-env passthrough, matches the design doc).
  - exec.Cmd.Stdout/Stderr go through a streamWriter that feeds a
    buffered sendBuf channel; a single sender goroutine owns the
    bidi stream's Send side so we never need to mutex Connect's
    BidiStream.
  - cmd.Wait blocks until both child exit AND exec's internal copy
    goroutines have drained, so all output frames precede Exit by
    construction (avoids the StdoutPipe-vs-Wait race).
  - Child runs in a fresh process group (Setpgid); signals are
    delivered to the whole group via kill(-pgid).
  - Command-not-found surfaces as on-stream Exit{127} (sh
    convention); signal kills surface as Exit{code: 128+signum,
    signal: <name>}.

Tests: 11 cases covering echo, stdin pump + CloseStdin, non-zero
exit, stderr capture, env passthrough, tty/empty-argv rejection,
command-not-found, signal kill, first-frame-must-be-Open, bearer
auth gating. `go test -race -count=10` clean.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(linode): cache cloud-init WG bootstrap — Phase A (FJB-99)

Foundation for the per-deployment ephemeral cache flow that FJB-91
called out as missing: each cache provisioned by fj-bellows now comes
up with its own WG keypair, brings up wg-quick@wg0 referencing the
orchestrator's pubkey, and publishes its own pubkey to S3 so the
orchestrator can read it back in Phase B.

What lands (Phase A):
- main.go loads or generates the orchestrator's WG keypair BEFORE
  prov.Configure runs (was deferred until wgboot.Boot). Pubkey is
  pushed into the Linode provider via SetOrchestratorWGPubkey for the
  cache cloud-init injector.
- managedCache carries orchestratorWGPubkey and renders four new
  fields into cacheCloudInitParams (OrchestratorWGPubkey, Orchestrator-
  WGAddr, CacheWGAddr, WGListenPort) when transport.mode is
  cache-gateway.
- cache-cloud-init.yaml.tmpl conditionally installs wireguard +
  awscli, drops /usr/local/sbin/fjb-wg-bootstrap.sh, and runs it from
  runcmd. The script:
  - generates a Curve25519 keypair on first boot (idempotent — re-
    runs reuse the persisted private key);
  - writes /etc/wireguard/wg0.conf with the orchestrator pubkey baked
    in as [Peer].PublicKey and the cache's address pinned at
    100.64.0.2/32;
  - brings up wg-quick@wg0;
  - publishes /etc/wireguard/publickey to s3://<bucket>/wg-pubkey.txt
    using the same Object Storage credentials already shared with
    zot — no new identity to provision.
- ssh-mode renders byte-identical to before (the WG bootstrap block
  is gated on OrchestratorWGPubkey).

Out of Phase A (lands in Phase B/C):
- Orchestrator-side WaitForWGPubkey polling against the bucket +
  feeding the discovered pubkey into wgboot.Boot as the peer config.
- bootWGStack ordering reorder (today still wgboot before cache.Ensure;
  Phase B moves it after).
- e2e harness reactivation: drop the persistent-cache preflight, use
  the ephemeral cache + bootstrap, and reactivate the worker readiness
  + job-complete checks.
- Drop the static transport.wg.peer.{public_key,endpoint} config
  knobs.

Tests cover the cache-gateway and ssh-mode render branches; verified
end-to-end against live Linode via test/e2e-linode/run-local.sh
--transport=cache-gateway (stack-up scope from FJB-91 still
passes — the new code path is exercised but worker validation is
the Phase C deliverable).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant